使用TS 开发 JS 虚拟机

July 21, 2020

背景

在我们的小程序&低代码平台中,有需要执行动态下发代码的诉求。世面上已知的一些 vm 没有完整通过 es5 或 es2015 测试用例的,无法在生产环境使用。因此需要自研一个 JSVM 引擎。

目前已放到 github 上 :jsvm 2 传送门

目前除了 WithStatement 语句外,其他 es5 特性均支持,单测覆盖 91%

原理

其实基于 语法树的 JSVM 思路很简单, 不断的前序遍历 AST 即可,我们只需要将 Babel 中的 Program VariableDeclaration 等节点均实现对应的方法即可,一个简单的 VM 如下

const babelParser = require("@babel/parser");
const babelTraverse = require("@babel/traverse").default;

// 执行引擎
class ExecutionEngine {
  constructor() {
    this.globalScope = {}; // 全局作用域
  }

  // 执行语法树
  execute(ast) {
    babelTraverse(ast, {
      enter: (path) => {
        const node = path.node;
        switch (node.type) {
          case 'Program':
            this.executeProgram(node);
            break;
          case 'VariableDeclaration':
            this.executeVariableDeclaration(node);
            break;
          case 'Identifier':
            return this.executeIdentifier(node);
          case 'NumericLiteral':
            return this.executeNumericLiteral(node);
          case 'BinaryExpression':
            return this.executeBinaryExpression(node);
          // 处理其他类型的语法树节点
          // ...
        }
      }
    });
  }

  // 执行 Program 节点
  executeProgram(node) {
    for (const statement of node.body) {
      this.execute(statement);
    }
  }

  // 执行 VariableDeclaration 节点
  executeVariableDeclaration(node) {
    const variableName = node.declarations[0].id.name;
    const variableValue = this.execute(node.declarations[0].init);
    this.globalScope[variableName] = variableValue;
  }

  // 执行 Identifier 节点
  executeIdentifier(node) {
    const variableName = node.name;
    return this.globalScope[variableName];
  }

  // 执行 NumericLiteral 节点
  executeNumericLiteral(node) {
    return node.value;
  }

  // 执行 BinaryExpression 节点
  executeBinaryExpression(node) {
    const leftValue = this.execute(node.left);
    const operator = node.operator;
    const rightValue = this.execute(node.right);

    // 执行二元运算
    switch (operator) {
      case '+':
        return leftValue + rightValue;
      case '-':
        return leftValue - rightValue;
      case '*':
        return leftValue * rightValue;
      case '/':
        return leftValue / rightValue;
      // 处理其他运算符
      // ...
    }
  }
}

// 输入 JavaScript 代码
const code = `
  let x = 5;
  x + 10;
`;

// 使用 Babel 解析代码为 AST
const ast = babelParser.parse(code, {
  sourceType: "module",
});

// 创建执行引擎并执行语法树
const engine = new ExecutionEngine();
engine.execute(ast);
console.log(engine.globalScope['x']); // 输出:5

完整实现步骤

  1. 词法分析(Lexical Analysis)和语法分析(Syntax Analysis):将输入的 JavaScript 代码转换为抽象语法树(Abstract Syntax Tree,AST)。这里直接使用 Babel 来完成
  2. 作用域分析(Scope Analysis):在语法树中进行作用域分析,确定变量的定义和引用所在的作用域。这可以通过构建作用域链(scope chain)来实现,每个作用域都有一个指向父级作用域的引用。参考 scope.ts
  3. 变量和函数声明(Variable and Function Declaration):在作用域分析的基础上,将变量和函数声明添加到适当的作用域中。这可以在作用域中创建变量和函数的绑定,并为后续的执行做准备。参考 declare
  4. 执行引擎(Execution Engine):遍历语法树,并执行相应的操作。执行引擎根据语法树节点的类型执行不同的操作,例如变量赋值、函数调用、条件判断等。引擎使用作用域链来解析变量和函数的引用,并根据运行时上下文执行相应的操作。参考 visitor 以及 standard
  5. 作用域和上下文管理:在执行过程中,需要管理作用域和运行时上下文。每次进入一个函数时,会创建一个新的函数执行上下文,包括函数的作用域、参数和局部变量。在函数执行完成后,上下文会被销毁。参考 Function
  6. 值的计算和存储:执行引擎根据需要计算表达式的值,并将结果存储在适当的位置,例如变量、对象属性或函数返回值。参考 statement. tsobject
  7. 控制流管理:执行引擎处理控制流语句,例如条件语句、循环语句和异常处理。根据条件的结果,执行引擎决定执行的路径,并更新程序计数器以指向下一条要执行的语句。参考 conditional
  8. 内建函数和对象:实现一些内建的 JavaScript 函数和对象,例如 console、Array、Object 等。这些函数和对象可以通过在执行引擎中添加相应的逻辑来实现。参考 context

效果

react-case 中我们可以看到 JSVM 2 可以完整的将 React 跑起来。事实上在我们自己的业务上,已经运行了很久没有出现任何问题。

性能

主要的开销在不断的创建上下文上,所以在存在大量 for 循环等场景时,性能相比未开启 JIT 的 v8 来说差了 2 个数量级。因此不适合大量数据计算。但是在常规业务场景用户实际体感不到。我们在移动端的低端机上对分类页这类有复杂交互场景进行录屏测试,没有明显差异。

压缩&混淆

我们实现的这版基于 babel 生成的 AST 有一个比较大的问题,如下图可以看到一个正常的 comsole. log 语句的原始 AST 会膨胀很大,因此使用了类似 AOT 的技术,我们在上生产环境之前做了一次 AST 等价的压缩处理,压缩后为下图所示,虽然比原始语句还要大一些,但是已经可以接受了。同时为了防止在端上解压缩带来的性能损耗,我们将 之前基于 babel 的 AST 协议 的 JSVM 直接重写成了基于我们压缩后的 AST (这部分没有开源,但是基本原理没有变化)。 image.png

线上问题排查

为了解决线上遇到动态后代码的报错无法定位的问题,我们基于压缩的 AST 自定义了一套 sourcemap 协议,并提供了对应的后台调试工具,直接输入报错的 ID,就可以反解析到对应的行列,如下图: image.png